Pinvon's Blog

所见, 所闻, 所思, 所想

类定义

类定义的形式:

class X{ ... };

一个类定义也常常说成类声明.

考点

  • 在类定义时, 能否对对象直接初始化?

不能. 因为在类定义时无法使用构造函数, 因此无法完成对象的初始化.

访问控制

class 默认为 private 权限, struct 默认为 public 权限.

构造函数

构造函数名与类名相同, 没有返回值, 支持重载.

静态成员

静态成员只有唯一的副本, 不像其他成员, 在每个对象中都拥有一份副本. 如:

class Date{
    ...
    static Date default_date;
public:
    ...
    static void set_default(...){
        Date::default_date = Date(...);
    }
};

考点

  • 一般数据类型, 静态数据类型的初始化时机.

一般数据类型必须在类内进行初始化; 静态成员变量必须在类外初始化; 静态成员常量必须在类中初始化(常量在声明时初始化, 所以只能在类中).

类对象的复制

在定义时赋初值时, 调用的是拷贝构造函数; 先定义后赋值, 调用的是重载的赋值运算符.

Date d = today;  // 使用拷贝构造函数
Date d;
d = today;  // operator= 重载函数

常量成员函数

在成员函数声明的参数表后面加上 const, 表示这个函数不会修改类的状态.

class X{
    int y;
public:
    int year() const;
};

inline int Date::year() const {
    return y++;  // error
}

inline int Date::year() {  // error 需要 const 后缀
    return y;
}

inline int Date::year() const {  // right
    return y;
}

const 对象, 非 const 对象, 都可以调用 const 成员函数;

非 const 成员函数只能调用非 const 对象. 如:

void f(Date& d, const Date& cd) {
	int i = d.year();  // ok
	d.add_year(1);  // ok
	int j = cd.year();  // ok
	cd.add_year(1);  // error 不能修改 const cd 的值
}

强制类型转换

有时候, 如果需要在 const 成员函数中修改类的状态, 可以使用蛮力:

string Date::string_rep() const {
    Date* th = const_cast<Date*>(this);  // 强制去掉 const
    th->compute_cache_value();
    th->cache_valid = true;
    return cache;
}

mutable

除了强制类型转换之外, 要修改类的状态, 还可以将状态声明成 mutable 的.

mutable 表示这个成员不可能是 const, 它以一种能允许更新的方式存储. 如:

class Date{
    mutable bool cache_valid;
public:
    string string_rep() cosnt;
};

string Date::string_rep() const {
    cache_valid = true;  // 修改类的状态
    ...
}

自引用

在一个非静态的成员函数里, this 是一个指针, 指向当时调用该成员函数的对象. 在类 X 的 非 const 成员函数里, this 的类型是 X*, 在类 X 的 const 成员函数里, this 的类型是 const X*.

内联函数

内联函数就是在类定义时, 就将成员函数也一起定义了, 而不仅仅是声明. 如:

class Date{
public:
    int day() const { return d; }
};

为了保持程序一致, 我们建议统一将成员函数的定义放在类定义之后, 所以可以使用 inline 关键字, 表示这个成员函数在类定义时就定义了. 如:

class Date{
public:
    int day() const;
};

inline int Date::day() const {
    return d;
}

设计一个日期类

声明

#include<iostream>
using namespace std;
class Date {
public:
    enum Month { jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec };
    class Bad_date {};  // 异常类
    Date(int dd=0, Month mm=Month(0), int yy=0);

    // 检查Date的函数
    int day() const;
    Month month() const;
    int year() const;
    string string_rep() const;  // 字符串表示
    void char_rep(char s[]) const;  // C风格字符串表示
    static void set_default(int, Month, int);

    // 修改Date的函数
    Date& add_year(int n);
    Date& add_month(int n);
    Date& add_day(int n);
private:
    int d, m, y;
    static Date default_date;
};

一个经典的类, 包括以下几组操作:

  • 一个构造函数, 描述这个类型的对象/变量应该如何初始化.
  • 一组 get 函数, 用于查看对象的状态, 并且应该标记为 const.
  • 一组 set 函数, 用于改变对象的状态.
  • 一个拷贝构造函数或重载赋值操作符, 使类可以自由复制.
  • 一个异常类, 用于报告异常情况.

在类外, 可以使用 Date::feb 来表示第二个月.

定义

每个成员函数, 都会有一个对应的实现. 如构造函数:

#include "Date.h"

Date::Date(int dd, Month mm, int yy) {
    if (yy == 0) yy = default_date.year();
    if (mm == 0) mm = default_date.month();
    if (dd == 0) dd = default_date.day();

    int max;

    switch(mm) {
        case feb:
            max = 28+leapyear(yy);
            break;
        case apr: case jun: case sep: case nov:
            max = 30;
            break;
        case jan: case mar: case may: case jul: case aug: case oct: case dec:
            max = 31;
            break;
        default:
            throw Bad_date();
    }
    if (dd<1 || max<dd) throw Bad_date();
    y = yy;
    m = mm;
    d = dd;
}

另外, 有一些辅助函数, 与类相关, 但未必要定义在类里(定义在类中会让类的界面过于复杂), 因为它们并不需要直接访问有关的表示. 如上面的 leapyear 函数:

bool leapyear(int y);

像这种函数, 我们可以放在与类的声明相同的头文件中, 如 Date.h

也可以将类和它的辅助函数放在同一个 namespace 里:

namespace Chrono {
    class Date { /* ... */ }
    bool leapyear(int y);
    // ...
};

具体的类

我们在上面的定义的 Date 类, 是一个具体的类型, 不会带来隐性的时间或者空间上的额外开销.

具体类型的大小在编译时已知晓, 因此这种对象可以在运行栈上分配, 无须使用自由存储操作.

对象

析构函数

析构函数的最常见用途是为了释放构造函数请求的存储空间. 如:

class Name {
    const char* s;
};

class Table {
    Name* p;
    size_t sz;
public:
    Table(size_t s=15) { p = new Name[sz = s]; }
    ~Table() { delete[] p; }
    // ...
}

默认构造函数

如果程序员没有声明构造函数, 则编译器会生成一个默认构造函数, 它将隐式地为 类类型的成员 和它的基类调用有关的默认构造函数.

如果程序员自己写了一个带有默认参数值的构造函数, 也可以将这个构造函数认为是默认构造函数, 如上面例子中的 Table 类.

考虑如下代码:

struct Tables {
    int i;
    int vi[10];
    Table t1;
    Table vt[10];
};

Tables tt;

Tables里面没有声明构造函数, 因此会调用默认构造函数. 默认构造函数只初始化类类型的成员, 所以它不会去初始化 tt.i, tt.vi, 因为它们不是类类型的对象.

const和引用必须在初始化列表中进行初始化, 而不能在构造函数中.

构造和析构

下面分情况讨论建立对象和销毁对象的时机.

局部对象

局部变量(栈上分配), 在程序遇到它时建立, 离开它所出现的块是销毁. 如:

void f() {
    Table aa;
    // ...
}

对象 aa 在函数返回时被析构.

对象的复制
void h() {
    Table t1;
    Table t2 = t1;
    Table t3;
    t3 = t2;
}

在这个例子中, 默认构造函数调用 2 次, 析构函数调用 3 次:

  • Table t1: 默认构造函数;
  • Table t2 = t1: 默认对象的赋值操作; 按成员赋值, 其中关于指针 p, 做法是 t2.p = t1.p, 没有内存分配, t1.p 和 t2.p 指向同一块内存.
  • Table t3: 默认构造函数; 为 t3.p 分配了一块内存.
  • t3 = t2: 原来的 t3.p 被 t2.p 覆盖, 此时 t1.p, t2.p, t3.p 指向同一块内存. 而原本的 t3.p 所占用的内存不再有指针指向它, 无法利用到, 资源浪费了.

在 h() 返回时, 进行了三次析构, 对 t1.p, t2.p, t3.p 所指的同一块内存删除了三次. 这种行为非常危险, 有可能第一次删除后, 该内存马上被利用, 然后又进行了第二次删除.

解决办法: 将 Table 的复制定义清楚.

class Table {
    Table(const Table&);
    Table& operator=(const Table&);
};

Table::Table(const Table& t) {
    p = new Name[sz=t.sz];
    for (int i=0; i<sz; i++) p[i] = t.p[i];
}

Table& Table::operator=(const Table& t) {
    if (this != &t) {  // 防止自赋值
        delete[] p;
        p = new Name[sz=t.sz];
        for (int i=0; i<sz; i++) p[i] = t.p[i];
    }
    return *this;
}

自由存储

自由存储对象(堆上分配), 使用 new 运算符建立, delete 运算符销毁.

类对象作为成员

class Club {
    string name;
    Table members;
    Table officers;
    Date founded;
    // ...
    Club(const string& n, Date fd);
};

Club::Club(const string& n, Date fd)
    : name(n), members(), officers(), founded(fd) {
    // ...
}

对于引用类型, const类型, 类类型的成员变量, 都建议在初始化列表中进行初始化. 这边重点讨论类类型的成员变量, 为什么要在初始化列表中进行初始化?

  • 必要性. 如果这个类成员变量的构造函数只有带参数这一种, 则它的声明要这样:
CMember* pm = new CMember;  // Error
CMember* pm = new CMember(2); // OK

所以, 如果 CMember 的对象是另一个类的成员, 则必须使用初始化列表:

CMyClass::CMyClass() : m_member(2) { // ... }
  • 效率. 编译器问题确保所有成员对象在构造函数执行之前初始化, 所以会先调用成员的默认构造函数, 完成成员初始化后再进入构造函数体中执行赋值操作. 所以如果在构造函数内赋值, 其实是调用了两次构造函数, 一次是进入构造函数之前, 另一次是在构造函数内部赋值; 如果在初始化列表中初始化, 则在构造函数内部就不再赋值了.

最后, 类类型的成员变量的构造函数调用顺序, 与它们在类中声明的顺序有关, 与它们在初始化列表中的顺序无关.

成员的复制

如果程序员没有编写拷贝构造函数和赋值操作, 则会自动生成. 所以如果想要禁止复制行为, 就要明确写出这两个函数, 并声明为私有. 如:

private:
    Unique_handle(const Unique_handle&);
    Unique_handle& operator=(const Unique_handle&);

另外, 系统生成的这两种函数, 执行的是浅拷贝, 所以如果确定要使用深拷贝, 需要自己小心编写.

数组

Table tbl[10];

这会建立一个包含 10 个 Table 的数组, 并用默认参数 15 调用 Table::Table() 进行初始化.

C 风格的数组:

Table* t1 = new Table;
Table* t2 = new Table[sz];

delete t1;
delete[] t2;

改成使用 STL:

vector<Table>* p1 = new vector<Table>(10);
delete p1;

静态局部存储

void f(int i) {
    static Table tbl;
    // ...
}

int main() {
    f(0);
    f(1);
}

f(0) 的时候会构造一次, 而 f(1) 时不再构造.

非局部存储

在所有函数之外定义的变量(即全局变量, 名字空间的变量, 各个类的 static 变量) 在 main() 执行前完成初始化(构造), 在 main() 结束后析构.

临时对象

与临时变量有关的问题都出在以低级的方式去使用高级的数据类型. 如:

const char* cs = (s1+s2).c_str();

为了保存 s1+s2 的结果, 将会产生一个临时对象, 然后从这个临时对象中提取一个指向 C 风格字符串的指针. 但是, 在表达式结束时, 这个临时对象被删除, cs 会指向一个已经释放的内存.

对象的放置

new 操作符将在堆上创建对象, 如果想要在其他地方创建对象, 可以重载 new 操作符(默认的 new 操作只有一个 size_t 类型的参数):

void* operator new(size_t, void* p) { return p; }

// 使用
void* buf = reinterpret_cast<void*>(0xF00F);  // 重要地址
X* p2 = new(buf) X;  // X 是一个类

其中, new(buf) X 相当于 new(sizeof(X), buf).

reinterpret_cast 是一种简单粗暴的类型转换运算符. 在大部分情况下, 它简单地产生一个值, 其二进制与原参数一致, 并且具有所需要的类型.

enum

由于 enum 无法知道存储在里面的对象的类型, 所以其成员不能是含构造/析构的成员, 因为无法保护其中的对象. 所以 enum 一般只作为类的实现中的一部分, 由类云维护有关在 enum 中存储的信息.

派生类

引入

首先考虑一段代码:

struct Employee {
    string first_name, family_name;
    char middle_initial;
      Date hiring_date;
      short department;
    // ...
};

struct Manager {
    Employee emp;  // 经理的雇佣记录
    list<Employee*> group;  // 所管理的人员
    short level;
    // ...
};

一个经理同时也是一个雇员, 所以在 Manager 对象的 emp 成员里存储着 Employee 数据. 但是对于编译器来说, 一个 Manager* 就不是 Employee*.

利用派生类, 可以实现, 一个 Manager 同时也是一个 Employee.

struct Manager : public Employee {
    list<Employee*> group;
    short level;
    // ...
};

由于一个 Manager 同时也是一个 Employee, 所以, 可以使用 Manager 的地方, 都可以使用 Employee 来替代. 如:

void f(Manager ml, Employee el) {
    list<Employee*> elist;
    elist.push_front(&ml);
    elist.push_front(&el);
    // ...
}

最后, 基类是一定要有定义的, 不能只声明. 如:

class Employee;
class Manager : public Employee { // 错误: Employee 无定义 
    // ...
};

成员函数

通过 public 继承的派生类, 可以使用基类的公有成员和保护成员, 但不能使用基类的私有成员.

构造函数和析构函数

在派生类的构造函数中, 如果基类有构造函数, 则必须调用这些构造函数(基类的构造函数如果没有参数, 则可以省略):

class Employee {
    string first_name, family_name;
    short department;
public:
    Employee(const string& n, int d);
};

class Manager : public Employee {
    list<Employee*> group;
    short level;
public:
    Manager(const string& n, int d, int lvl);
};

Employee::Employee(const string& n, int d)
    : family_name(n), department(d) {
    // ...
}

Manager::Manager(const string& n, int d, int lvl)
    : Employee(n, d),  // 初始化基类
      level(lvl) {
    // ...
}

注意, 派生类的构造函数不能直接去初始化基类的成员, 必须借用基类的构造函数来初始化.

类对象的构造顺序: 首先是基类, 然后是成员, 最后是派生类本身; 析构则相反.

复制

类对象的复制由拷贝构造函数和赋值操作定义, 如:

class Employee {
    Employee& operator=(const Employee&);
    Employee(const Employee&);
};

void f(const Manager& m) {
    Employee e = m;  // 调用拷贝构造函数
    e = m;  // 调用赋值操作符
}

这种复制行为, 只会将 Manager 的 Employee 部分复制过来, 从而产生"切割". 如果使用的是类对象的指针或引用来进行传递, 则可以避免"切割"问题.

虚函数

虚函数使程序员可以在碁类里声明一些能够在各个派生类里重新定义的函数. 编译器和装载程序能保证对象和应用于它们的函数之间的正确对应关系. 如:

class Employee {
    string first_name, family_name;
    short department;
public:
    Employee(const string& name, int dept);
    virtual void print() const;
};

void Employee::print() const {  // ...  }

在派生类中, 虚函数的参数类型不允许修改, 但返回类型可以有稍微改变.

虚函数也必须有定义, 纯虚函数则可以不定义.

class Manager : public Employee {
public:
    void print() const;
};

// ...

这样一来, 派生类中的 print() 就会覆盖基类中的 print().

除非明确说明需要调用的虚函数的版本(如, Employee::print()), 否则, 在对一个对象调用虚函数时, 被选用的总是那个最适于它的覆盖函数. 这就是 多态性. 一个带有虚函数的类型被称为是一个 多态类型. 要在 C++ 里取得多态行为, 被调用的函数就必须是虚函数, 而对象则必须是通过指针或者引用去操作的. 如果直接操作一个对象(而不是通过指针或引用), 它的确切类型就已经为编译器所知, 因此也就不需要运行时的多态性了.

抽象类

纯虚函数就是在虚函数后面加上 =0. 如:

class Shape {
public:
    virtual void rotate(int) = 0; 
    virtual void draw() = 0;
    virtual bool is_closed() = 0;
};

纯虚函数可以不进行定义. 如果一个类里存在纯虚函数, 这个类就是抽象类.

抽象类只能用做界面, 作为其他类的基类. 如:

class Circle : public Shape {
public:
    void rotate(int) { }
    void draw();
    bool is_closed() { return true; }
};

// ...

如果抽象类的派生类没有定义抽象类中的所有纯虚函数, 则派生类仍是一个抽象类.

class Polygon : public Shape {
public:
    bool is_closed() { return true; }
    // 未定义 draw() 和 rotate()
};
Polygon b;  // Error, Polygon 是个抽象类

类层次结构

多重继承

多重继承是指派生类有多个直接基类.

多重继承会带来歧义, 如多个基类可能会出现同名的成员函数, 在派生类中, 需要明确指明这个成员函数是哪个基类的, 如:

void f(Satellite* sp) {
    debug_info* dip = sp->get_debug();  // Error
    dip = sp->Task::get_debug();  // OK
    dip = sp->Displayed::get_debug();  // OK
}

也可以在 Satellite 类中直接定义一个 get_debug(), 然后在内部调用两个基类的 get_debug().

如果 A 是基类, B 继承自 A, C 继承自 A, D 继承自 B 和 C, 如果在 D 中调用 A 的成员, 就会产生歧义. 因为编译器不知道调用的是 A->B->D 路线中的 A, 还是 A->C->D 中的 A. 所以, 公共基类不应该表示为两个分离对象, 它们应该用虚基类来表示.

虚基类

虚基类就是在继承时, 多加一个 virtual 关键字. 如:

class A {};
class B : public virtual A {};
class C : public virtual A {};
class D : public B, public C {};

这个时候, B 和 C 共用同一个基类 A, 而不会保存多个 A 的副本.

访问控制

对于成员: 0.png

对于继承:

  public protected private
公有继承 public protected 不可见
保护继承 protected private 不可见
私有继承 private private 不可见

运行时类型信息(RTTI, Run Time Type Information)

RTTI 是指在运行时进行类型识别, C++ 引入这个机制是为了让程序在运行时能根据基类的指针或引用来获得该指针或引用所指的对象的实际类型.

dynamic_cast

dynamic_cast 运算符有两个参数, 一个是 <type>, 一个是 () 里面的指针或引用. 如:

dynamic_cast<T*> (p)

例子:

class Employee {};  // Manager 内部有 virtual 函数
class Manager : public Employee {};

int main() {
    Manager m;
    Employee& e = m;  // 向上转型, 可以直接转, 也可以用 dynamic_cast
    Manager& new_m = dynamic_cast<Manager&>(e);  // 向下转型, 从 Employee 转到 Manager, 使用 dynamic_cast
}

换个角度想就很清楚了, 一个 Manager 同时也必定是一个 Employee, 所以从 Manager 转到 Employee 就可以直接转, 而一个 Employee 未必是一个 Manager, 所以从 Employee 转到 Manager 就需要借助运算符 dynamic_cast.

dynamic_cast 要求第二个参数必须是指向 多态类 的指针或引用, 否则会出错. 如上面的例子中, Employee 必须包含虚函数.

在 C++ 中, 如果一个类中含有虚函数, 则编译器会构建出一个虚函数表来指示这些函数的地址, 如果派生类定义并实现了一个同名函数, 则虚函数表会将该函数指向新的地址.

使用 dynamic_cast 后必须进行检查, 因为如果转换失败, 会返回空指针.

考虑如下代码:

class Component : public virtual Storable { /* ... */ };
class Receiver : public Component { /* ... */ };
class Transmitter : public Component { /* ... */ };
class Radio : public Receiver, public Transmitter { /* ... */ };

继承图如下所示: 1.png

一个 Radio 对象里包含了两个 Component 对象, 所以如果对一个 Radio 里用 dynamic_cast 从 Storable 出发到 Component, 就会因为歧义性而返回 0. 如:

void h1(Radio& r) {
    Storable* ps = &r;
    // ...
    Component* pc = dynamic_cast<Component*>(ps);  // pc = 0
}

static_cast

dynamic_cast 能从多态性的虚基类强制到某个派生类或者兄弟类, 而 static_cast 不检查被强制的对象, 所以它做不到这些:

void g(Radio& r) {
    Receiver* prec = &r;    // Receiver 是 Radio 的常规基类
    Radio* pr = static<Radio*>(prec);   // 可以, 不检查
    pr = dynamic_cast<Radio*>(prec);    // 可以, 运行时检查

    Storable* ps = &r;  // Storable 是 Radio 的虚基类
    pr = static_cast<Radio*>(ps);   // 错误, 不能从虚基类强制
    pr = dynamic_cast<Radio*>(ps);  // 可以, 运行时检查
}

尽管 dynamic_cast 会带来一些额外的开销, 但还是更加推荐使用 dynamic_cast 来在类的层次结构中进行强制转换, 因为它比 static_cast 更加安全. 但有一种情况, 必须使用 static_cast.

dynamic_cast 不能从 void* 出发进行强制, 因为它必须去查看对象, 以确定其类型:

Radio* f(void* p) {
    Storable* ps = static_cast<Storable*>(p);  // 相信程序员
    return dynamic_cast<Radio*>(ps);
}

类对象的构造和析构

不要在构造和析构函数中调用虚函数. 如果在派生类的构造函数中调用了虚函数, 此时由于派生类对象正在被构造, 所以调用的虚函数只能是基类的虚函数, 而不是派生类的实现版本.

typeid 操作符

typeid 是一个运算符, 而不是函数, 这点与 sizeof 一样.

typeid 用来在运行时取得变量的类型. 如:

int ia=3;
if(typeid(ia) == typeid(int)) {
    cout << "int" << endl;
}

C++ 为了支持 RTTI, 提供了两个操作符: dynamic_cast 和 typeid. typeid 操作符的返回结果是 type_info 类的对象的引用(在 typeinfo 头文件中定义). type_info 的确定定义与编译器有关.

type_info 将默认构造函数, 拷贝构造函数和赋值操作符都定义为 private, 所以不能定义或复制 type_info 类型的对象. 要想创建 type_info 对象, 只能使用 typeid 操作符, 所以如果把 typeid 当成函数, 它就是 type_info 类的友元函数.

typeid() 常用于找出由一个引用或者指针所引用的对象的确切类型, 如果一个多态类型的指针或引用的操作对象的值是 0, typeid() 将抛出一个 bad_typeid 异常. 如果 typeid() 的操作对象的类型不是多态的, 其结果在编译时就会确定.

除了和上面的例子一样, 用 == 来检测 type_info 对象是否相等之外, 还可以直接获取类的名字:

#include <typeinfo>
void g(Component* p) {
    cout << typeid(*p).name();
}

Comments

使用 Disqus 评论
comments powered by Disqus